Skip to content

fix: stop submitting truncated reasoning as the browsecomp-plus answer - #61

Open
shehabyasser-scale wants to merge 1 commit into
mainfrom
fix/browsecomp-plus-truncated-final-answer
Open

fix: stop submitting truncated reasoning as the browsecomp-plus answer#61
shehabyasser-scale wants to merge 1 commit into
mainfrom
fix/browsecomp-plus-truncated-final-answer

Conversation

@shehabyasser-scale

@shehabyasser-scale shehabyasser-scale commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Stacked on pr3-harness-bench (#46). One file: the BrowseComp+ baseline agent.

The scoring bug

When a completion came back with finish_reason == "length", the agent submitted message.content verbatim as its final answer:

if not calls:
    if (message.content or "").strip():
        await self._submit(environment, message.content)

This target writes its chain-of-thought into ordinary content (it has no separate reasoning_content channel) and routinely runs past the token cap. So what got submitted was not an answer, it was 42k to 52k characters of truncated, mid-sentence reasoning. The judge scores that 0 every time, and the trial is indistinguishable from a genuine wrong answer.

Measured across the smoke runs carrying this change: 6 length-truncated turns over 3 trials, content length min 42,253 / median 46,793 / max 52,104 chars. The pre-fix baseline traces (198 trials over 3 rounds) contain no finish_reason field at all, which is exactly why this went unnoticed. The instrumentation added here is what surfaced it.

Truncated turns are now recoverable rather than final: the model is asked for only the three required labeled lines instead of having its unfinished thinking submitted on its behalf.

Supporting changes

All four target the same failure mode of losing a trial that had real retrieved evidence behind it.

Wall-clock deadline with a final-answer reserve. Turn count is not this agent's binding constraint. Most of a turn is sequential search.py invocations, each booting a fresh pyserini/Lucene JVM against the BM25 index (~13s apiece) against ~3-9s of inference. Trials ran out of clock long before MAX_TURNS, so the existing turn-budget fallback never fired and harbor killed the trial mid-search with no answer recorded. There is now a deadline (BROWSECOMP_AGENT_BUDGET_SEC, default 900) with a reserve (BROWSECOMP_FINAL_RESERVE_SEC, default 120) so the best-effort answer is written before harbor's AgentTimeoutError. Harbor does not tell an agent its own timeout, hence the env var; keep it in step with the run's --agent-timeout-multiplier.

Tool calls past the deadline are stubbed, never skipped, because every tool_call still needs a matching tool message or the forced-final request is rejected for an unanswered call.

Completion shape validation. A gateway can answer 200 with a body the SDK does not turn into a completion, observed once as 'str' object has no attribute 'usage', which crashed the trial on an attribute access two lines later. _complete() now rejects anything without usable .choices in one place.

Empty turns nudge instead of raising. raise RuntimeError("model returned neither a response nor a tool call") became a bounded retry, giving up only after MAX_EMPTY_RETRIES consecutive empty turns. An assistant message carrying neither content nor tool calls is dropped rather than appended, since strict OpenAI-compatible servers reject it.

Bounded forced-final. The last-resort call uses max_retries=1 (the SDK default of 8 could outlast the reserve on its own) and ANSWER_MAX_TOKENS (so the model cannot spend the whole reserve on chain-of-thought and get truncated a second time), and degrades to a stub answer on failure instead of propagating.

Extra trace fields. finish_reason, per-turn llm_sec, left_sec, and a forced_final_reason of deadline vs turns, which lets a post-hoc conservative score treat exactly the trials the pre-fix agent would have lost as zeros.

Rebased onto the current base

pr3-harness-bench moved (and its history was rewritten) after this branch was cut, so the original commit no longer applied. Rebuilt as one commit on 5727a44.

The _is_reasoning_model overlap flagged in the first version of this PR is resolved: #54's version of that gate has landed upstream and is kept verbatim here. All this PR now does to _completion_kwargs is route the already-computed token-limit key through a new max_tokens parameter, which the bounded forced-final call needs:

-        kwargs[_token_limit_key] = 12_000
+        kwargs[_token_limit_key] = max_tokens

Also fixed in the rebase

The first version broke the existing test suite. _complete reaches the client through with_options(...), and the test fake was a bare SimpleNamespace, so test_agent_submits_response_and_populates_context died on AttributeError: 'types.SimpleNamespace' object has no attribute 'with_options'. The fake is now a small FakeClient that implements it. Nothing in CI runs these target test suites (the repo has no workflows; the only checks are Greptile and Socket), which is why a red suite was not visible on the PR.

Three tests added, each verified to fail without its fix:

Test Guards
test_truncated_reasoning_is_never_submitted_as_the_answer Without the guard, the 40k-char ramble is submitted verbatim. This is the PR's headline claim, previously untested.
test_repeated_truncation_is_reported_as_empty_retries Reports turns instead of empty_retries without the new flag.
FakeClient.with_options The pre-existing test that the first version broke.

Both Greptile findings addressed:

  1. Stubbed tool calls produced no trace events. The per-call _trace sat inside the executed branch, so deadline-stubbed calls were invisible in the JSONL exactly when a post-hoc audit needs them. Moved out so both paths are traced.
  2. forced_final_reason conflated two causes. empty_turns >= MAX_EMPTY_RETRIES also reported "turns", so a trial that gave up on turn 4 after three truncated turns was indistinguishable from one that used all 32. There is now a third value, empty_retries, and the reason is computed once and used by both the trace and context.metadata.

Verification

  • pytest tests/ in browsecomp-plus/baseline/target: 5 passed.
  • pytest tests/test_v05_benchmark_configs.py: 13 passed, 6 skipped.
  • Each new test confirmed red with its fix reverted and green with it restored.
  • Exercised live in the smoke-browsecomp-plus-qwen fix1/fix2 runs cited above.

🤖 Generated with Claude Code

Greptile Summary

This PR fixes a scoring bug in the BrowseComp+ baseline agent where a finish_reason == "length" response (truncated mid-sentence reasoning) was submitted verbatim as the final answer, scoring 0 every time. It also adds a wall-clock deadline to prevent harbor from killing trials mid-search with no answer recorded.

  • Headline fix: finish_reason == "length" turns are now treated as empty retries (with a nudge to produce only the three labeled lines) rather than submitted as the answer; after MAX_EMPTY_RETRIES consecutive truncated/empty turns the agent falls through to the forced-final path.
  • Deadline guard: AGENT_BUDGET_SEC / FINAL_RESERVE_SEC env-var constants bound wall-clock time; tool calls past the deadline are stubbed (not skipped) so the forced-final request is never rejected for unanswered tool calls.
  • Robustness hardening: _complete() validates response shape before attribute access, the forced-final call uses max_retries=1 and ANSWER_MAX_TOKENS to avoid re-truncation, and forced_final_reason now distinguishes deadline / empty_retries / turns for post-hoc scoring.
  • Test suite: FakeClient.with_options repairs the pre-existing broken test; two new regression tests (TruncatedThenAnswer, AlwaysTruncated) were each verified red before their respective fixes.

Confidence Score: 5/5

Safe to merge. The fix is narrowly scoped to the BrowseComp+ baseline agent, all three exit paths are now correctly distinguished in metadata, and every changed behaviour is covered by a regression test verified red before its fix.

The change correctly guards the truncation bug, adds a wall-clock deadline that prevents silent trial loss, and restores the previously-broken test suite. The forced-final path degrades gracefully to a stub on any exception, so no code path can propagate an unhandled error. No logic errors or data-correctness issues were found.

Files Needing Attention: No files require special attention. The only note is three inline numeric literals (15, 20, 30) in agent.py that could join the existing named-constant block at the top of the file.

Important Files Changed

Filename Overview
harness-engineering-bench/browsecomp-plus/baseline/target/src/browsecomp_plus_agent/agent.py Core agent rewritten with wall-clock deadline, truncated-reasoning guard, bounded empty retries, _complete() shape validation, and improved forced-final path. Logic is sound and well-tested.
harness-engineering-bench/browsecomp-plus/baseline/target/tests/test_agent.py Adds FakeClient with with_options support to fix a pre-existing broken test, plus two new regression tests (TruncatedThenAnswer, AlwaysTruncated) that each verified red before their fix.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[run starts] --> B{remaining le FINAL_RESERVE_SEC?}
    B -->|yes| DEADLINE[out_of_time=True, break]
    B -->|no| C[_complete with timeout]
    C --> D{finish_reason}
    D -->|length truncated=True| E{empty_turns ge MAX_EMPTY_RETRIES?}
    D -->|stop with content| SC[_submit content, completed=True]
    D -->|tool_calls| T[process tool calls]
    E -->|yes| OOA[out_of_answers=True, break]
    E -->|no| NUDGE[append nudge message, continue]
    NUDGE --> B
    T --> DC{deadline hit mid-turn?}
    DC -->|yes stub result| TR[_trace event both paths]
    DC -->|no execute tool| TR
    TR --> SR{submit_response called?}
    SR -->|yes| DONE[completed=True, break]
    SR -->|no| OT{out_of_time?}
    OT -->|yes| DEADLINE
    OT -->|no| B
    DEADLINE --> FF
    OOA --> FF
    FF[forced-final: reason = deadline or empty_retries or turns]
    FF --> FFC[_complete tools=False, max_retries=1, ANSWER_MAX_TOKENS]
    FFC -->|success| FS[_submit answer]
    FFC -->|Exception| STUB[_submit stub answer]
    FS --> META[context.metadata forced_final_reason]
    STUB --> META
Loading

Reviews (3): Last reviewed commit: "fix: stop submitting truncated reasoning..." | Re-trigger Greptile

Context used:

  • Rule used - Store magic numbers as class or instance variables... (source)

Learned From
scaleapi/scaleapi#126388

@varunursekar
varunursekar force-pushed the pr3-harness-bench branch 2 times, most recently from 31d8b79 to ab8717b Compare July 28, 2026 02:25
@shehabyasser-scale
shehabyasser-scale force-pushed the fix/browsecomp-plus-truncated-final-answer branch 2 times, most recently from 9b366c8 to 84f4aef Compare July 28, 2026 14:08
The headline is a scoring bug. When a completion came back with
`finish_reason == "length"`, the agent submitted `message.content`
verbatim as its final answer. This target writes its chain-of-thought
into ordinary `content` (it has no separate reasoning_content channel)
and routinely runs past the token cap, so what got submitted was 46-52k
characters of truncated mid-sentence reasoning. The judge scores that 0
every time, and the trial looks like a genuine wrong answer rather than
a harness failure. Truncated turns are now treated as recoverable: the
model is asked for just the three required labeled lines instead of
having its thinking submitted for it.

Four supporting changes, all aimed at the same failure mode of losing a
trial that had real retrieved evidence behind it:

- Wall clock, not turn count, is this agent's binding constraint. Most
  of a turn is sequential `search.py` calls, each booting a fresh
  pyserini/Lucene JVM against the BM25 index (~13s apiece) against ~3-9s
  of inference, so trials ran out of clock long before MAX_TURNS. The
  turn-budget fallback therefore never fired and harbor killed the trial
  mid-search with no answer recorded. There is now a deadline
  (BROWSECOMP_AGENT_BUDGET_SEC) with a final-answer reserve
  (BROWSECOMP_FINAL_RESERVE_SEC) so the best-effort answer is written
  before harbor's AgentTimeoutError. Tool calls past the deadline are
  stubbed rather than skipped, because every tool_call still needs a
  tool message or the forced-final request is rejected.

- Completion shape is validated in one place. A gateway can answer 200
  with a body the SDK does not turn into a completion, observed as
  `'str' object has no attribute 'usage'`, which crashed the trial on an
  attribute access two lines later. Anything without usable `.choices`
  is now rejected as a retryable error.

- An empty or malformed assistant turn nudges instead of raising
  `RuntimeError("model returned neither a response nor a tool call")`,
  giving up only after MAX_EMPTY_RETRIES consecutive nudges. An
  assistant message carrying neither content nor tool calls is dropped
  rather than appended, since strict OpenAI-compatible servers reject it.

- The forced-final call is bounded (max_retries=1, ANSWER_MAX_TOKENS) and
  degrades to a stub answer on failure rather than propagating, and the
  trace/metadata now record finish_reason, per-turn latency, remaining
  budget, and a `forced_final_reason` so a post-hoc conservative score
  can treat exactly the trials the pre-fix agent would have lost as
  zeros.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@shehabyasser-scale
shehabyasser-scale force-pushed the fix/browsecomp-plus-truncated-final-answer branch from 84f4aef to f8b81b5 Compare July 29, 2026 02:56
shehabyasser-scale added a commit that referenced this pull request Jul 29, 2026
Nothing in this repository ran tests. The only checks on a pull request were
Greptile Review and Socket Security, neither of which invokes pytest, and there
was no .github directory on any branch. #61 sat open with a red target suite
because of it: the change there reaches the OpenAI client through
`with_options`, the test fake did not implement it, and
`test_agent_submits_response_and_populates_context` had been failing on
AttributeError since the PR was opened with nothing to surface it.

Two jobs, seven suites, ~450 vero tests plus 20 across the agents:

- `vero`: uv sync --locked, then pytest. Two placeholder credentials are set
  because the task compiler refuses to emit a task whose declared credentials
  are absent from the environment. Without them five build tests fail on
  "declared task credentials are missing", which reads like a code failure and
  is not one. Setting VERO_SKIP_SECRET_CHECK instead would be wrong: it makes
  test_compiler_checks_secrets_before_writing_and_rejects_source_overlap
  vacuous, since that test asserts the check fires.
- `targets`: one job per baseline agent, fail-fast off so a single broken agent
  does not mask the other five.

Verified locally at Python 3.12 against ed55bc7 before committing: vero 447
passed / 11 skipped, browsecomp-plus 3, gaia 2, officeqa 2, swe-atlas-qna 2,
swe-bench-pro 3, tau3 8. `uv lock --check` is clean in all seven projects, so
--locked will not spuriously fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@shehabyasser-scale
shehabyasser-scale changed the base branch from pr3-harness-bench to main July 29, 2026 02:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant